spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import { notFound, permanentRedirect } from 'next/navigation';3import { Suspense } from 'react';4import { SectionSkeleton } from '@/components/satellite/skeleton';5import { Hero, TelemetryPanel } from '@/components/satellite/hero';6import { LiveMap } from '@/components/satellite/live-map';7import { Block, Head, Note } from '@/components/satellite/primitives';8import { EventsSection, HistorySection, IdentifiersSection, LaunchSection, MissionSection, OrbitSection, OwnershipSection, RegistrationSection, RelatedSection, SourcesSection } from '@/components/satellite/sections';9import { ViewBeacon } from '@/components/satellite/view-beacon';10import { Container } from '@/components/ui/section';11import { api, ApiError, safe } from '@/lib/api';12import { fmtDate, fmtInt } from '@/lib/format';13import { MISSION_LABELS, OBJECT_TYPE_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site';14import type { SatelliteDetail } from '@/lib/types';1516type Params = { params: Promise<{ slug: string }> };1718async function load(slug: string): Promise<SatelliteDetail> {19 try {20 const res = await api.satellite(slug);21 return res.data;22 } catch (e) {23 if (e instanceof ApiError && e.notFound) notFound();24 throw e;25 }26}2728function describe(d: SatelliteDetail): string {29 const bits: string[] = [];30 bits.push(`${d.name} (NORAD ${d.norad_id ?? '—'}${d.cospar_id ? `, COSPAR ${d.cospar_id}` : ''}) is a${d.status === 'ACTIVE' ? 'n active' : ` ${d.status.toLowerCase()}`} ${(OBJECT_TYPE_LABELS[d.object_type] ?? d.object_type).toLowerCase()}`);31 if (d.orbit_class) bits.push(`in ${d.orbit_class}`);32 if (d.operator_name) bits.push(`operated by ${d.operator_name}`);33 if (d.launch_date) bits.push(`launched ${fmtDate(d.launch_date)}${d.launch_site_name ? ` from ${d.launch_site_name}` : ''}`);34 let s = bits.join(' ') + '.';35 if (d.orbital_state) s += ` Perigee ${fmtInt(d.orbital_state.perigee_km)} km, apogee ${fmtInt(d.orbital_state.apogee_km)} km, inclination ${d.orbital_state.inclination.toFixed(2)}°.`;36 else if (d.decay_date) s += ` Decayed ${fmtDate(d.decay_date)}.`;37 s += ' Live position, orbital elements, history and sources on SatelliteIndex.';38 return s;39}4041export async function generateMetadata({ params }: Params): Promise<Metadata> {42 const { slug } = await params;43 const d = await safe(api.satellite(slug));44 if (!d) return { title: 'Satellite', robots: { index: false } };45 const s = d.data;46 const title = `${s.name} — Live Orbit, NORAD ${s.norad_id ?? '—'}`;47 const description = describe(s);48 const canonical = routes.satellite(s.slug);49 return {50 title,51 description,52 alternates: { canonical },53 openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME },54 twitter: { card: 'summary_large_image', title, description },55 };56}5758/** Streams after the shell: the history endpoint is the slowest call and must never delay the redirect/404 decision. */59async function OrbitWithHistory({ d }: { d: SatelliteDetail }) {60 const history = d.norad_id !== null ? ((await safe(api.satelliteHistory(d.slug)))?.data ?? null) : null;61 return <OrbitSection d={d} history={history} />;62}6364export default async function SatellitePage({ params }: Params) {65 const { slug } = await params;66 const d = await load(slug);67 if (d.redirected_from || d.slug !== slug) permanentRedirect(routes.satellite(d.slug));6869 const ident = String(d.norad_id ?? d.slug);70 const hasElements = d.orbital_state !== null;7172 const jsonLd = {73 '@context': 'https://schema.org',74 '@type': 'Thing',75 name: d.name,76 alternateName: d.aliases.map((a) => a.alias).filter((a) => a !== d.name),77 identifier: [78 d.norad_id !== null ? { '@type': 'PropertyValue', propertyID: 'NORAD', value: String(d.norad_id) } : null,79 d.cospar_id ? { '@type': 'PropertyValue', propertyID: 'COSPAR', value: d.cospar_id } : null,80 ].filter(Boolean),81 url: `${SITE_URL}${routes.satellite(d.slug)}`,82 description: describe(d),83 additionalType: 'https://schema.org/Dataset',84 subjectOf: { '@type': 'Dataset', name: `${d.name} orbital elements`, description: 'Latest two-line element set and derived orbital parameters', license: 'https://celestrak.org/NORAD/documentation/', isAccessibleForFree: true, creator: d.sources.map((s) => ({ '@type': 'Organization', name: s.name })) },85 };8687 return (88 <Container wide>89 <ViewBeacon type="satellite" id={d.id} />90 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />9192 <Hero d={d} />9394 {/* Two-column terminal layout. DOM order = visual order on every breakpoint: live map → telemetry panel → deep sections. */}95 <div className="lg:grid lg:grid-cols-[minmax(0,1fr)_340px] lg:gap-x-10 xl:grid-cols-[minmax(0,1fr)_380px]">96 <Block id="live" className="lg:col-start-1 lg:row-start-1 lg:pt-0">97 <Head eyebrow="Live position" title={hasElements ? 'Ground track & current position' : 'Live position'}>98 {hasElements && <p className="mt-1 text-xs text-ink-3">Propagated client-side every 5 s from the latest element set (SGP4). Accuracy degrades with element age.</p>}99 </Head>100 {hasElements ? (101 <LiveMap ident={ident} name={d.name} initial={d.live && d.live.error == null ? d.live : null} sourceEpoch={d.orbital_state?.epoch ?? null} />102 ) : (103 <Note>104 Live position unavailable — {d.status === 'DECAYED' ? `this object re-entered the atmosphere${d.decay_date ? ` on ${fmtDate(d.decay_date)}` : ''} and is no longer tracked.` : 'no public orbital element set exists for this object (it may be classified, untracked or too small to catalogue).'}105 </Note>106 )}107 </Block>108109 <aside className="lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:self-start lg:pt-0 lg:sticky lg:top-[calc(var(--header-h)+1rem)]" aria-label="Telemetry readout">110 <div className="py-6 lg:py-0">111 <TelemetryPanel d={d} />112 </div>113 </aside>114115 <div className="lg:col-start-1 lg:row-start-2 divide-y divide-[color:var(--rule)]">116 <Suspense fallback={<SectionSkeleton rows={12} chart title="Loading orbital elements" />}>117 <OrbitWithHistory d={d} />118 </Suspense>119 <MissionSection d={d} />120 <OwnershipSection d={d} />121 <LaunchSection d={d} />122 <HistorySection d={d} />123 <RegistrationSection />124 <SourcesSection d={d} />125 <EventsSection d={d} />126 <RelatedSection d={d} />127 <IdentifiersSection d={d} />128 </div>129 </div>130131 <p className="pb-10 pt-4 text-2xs text-ink-3">132 Orbit class, mission type and constellation membership are derived by SatelliteIndex ({MISSION_LABELS[d.mission_type ?? 'unknown'] ?? d.mission_type}) — see the <a className="hover:text-accent" href={routes.methodology()}>methodology</a>. Positions are propagated estimates, not tracking measurements; never use them for conjunction assessment.133 </p>134 </Container>135 );136}137